Skip to content

QVAC-21981 feat: ABot-World interactive walk sessions + native scene creation - #22

Open
DmitryMalishev wants to merge 19 commits into
2026-07-03from
feature-abot-dit
Open

QVAC-21981 feat: ABot-World interactive walk sessions + native scene creation#22
DmitryMalishev wants to merge 19 commits into
2026-07-03from
feature-abot-dit

Conversation

@DmitryMalishev

@DmitryMalishev DmitryMalishev commented Jul 17, 2026

Copy link
Copy Markdown

What

Adds full ABot-World support to the engine: model loading, the interactive walk session, and native scene creation. ABot-World-0-5B-LF (Apache-2.0) is a causal, interactive derivative of Wan2.2-TI2V-5B driven block-by-block by keyboard actions. See docs/abot_world.md for the feature overview.

Increments, in review order:

  1. Model support: VERSION_ABOT_WORLD detection (via the act_control_adapter.conv.weight tensor), the ActControlAdapter block (keyboard-action conditioning), Wan2.2-TI2V-5B-family loading, and a guard that rejects ABot models in the batch generate_image()/generate_video() APIs at the shared GenerationRequest::resolve() choke point. Both capability queries report false for ABot.
  2. Interactive walk (src/abot_world.hpp + session C API): the causal walk core — recompute formulation with a per-row trailing-window mask that reproduces the reference KV-cache semantics exactly (action adapter, zero-masked reference tokens, negative-time RoPE ids, per-frame timestep modulation, 4-step distilled flow matching, taehv per-block decode with 3-latent-frame overlap). Public API: sd_abot_session_{params_init,new,step,frames_free,free}; step(action_mask) (bits 0..7 = W,A,S,D,I,J,K,L) returns the next block's decoded RGB frames.
  3. Bounded memory + opt-in KV cache (ABOT_KV_CACHE=1): history K/V captured once per finalized block into per-layer base/ring tensors instead of recomputed every denoise step (~3.7x fewer frame-passes per block in steady state); walk-graph memory bounded by the attention window. Correct on shared/single-GPU backends (decode/append overlap serialized when DiT and taehv share a backend).
  4. Quantized-DiT correctness: DiT params now load in their GGUF type (a params-prefix bug forced F32 allocation) — halves weight VRAM and lets Q8_0 run natively.
  5. Native scene creation (sd_abot_scene_create + sd-abot-session --mode create-scene): umT5-XXL prompt encode (masked, padding rows zeroed) + Wan2.2 VAE first-frame encode (PIL-fit-equivalent aspect crop + antialiased Catmull-Rom resize) + zero-filled reference slots, written as the scene-pack safetensors the session loads. Replaces the reference implementation's offline PyTorch extraction.
  6. Text-only packs, gated (first_frame_mask): the format and walk support packs without a first-frame image (block 0 from noise), but the distilled checkpoint cannot bootstrap a coherent first frame, so the capability is gated and documented; front-ends should require an image. Back-compatible: packs without the mask tensor behave exactly as before.

Validation (vs the PyTorch reference)

  • Per-step parity across every attention regime — first block, all four timesteps, history recompute, window onset, deep eviction: 7/7 gates at cosine 0.9996–0.9999 (gate 0.999).
  • Full-chain golden-replay walk (4 blocks x 4 steps, replaying recorded reference noise): latent cosine 0.9999 → 0.9931; decoded frames PSNR 31.3–55 dB (mean 38.9).
  • taehv decode vs the reference streaming decoder on identical latents: mean 46.4 dB, min 33.5 dB.
  • KV-cache path equals the recompute path on CPU (the correctness oracle) and passed golden gates on CUDA (RTX 5090) and Metal (M3 Ultra).
  • Native scene creation vs a golden PyTorch-extracted pack: prompt_embeds cosine 0.9973 (CPU) / 0.9998 (CUDA), first_frame_latents 0.9987 (gate 0.995); a walk on the native pack tracks the golden-pack walk at 32.4 dB mean PSNR (Windows/Vulkan) / 26.6 dB (CUDA). Q8_0 umT5 clears the tensor gate on CUDA (0.9969) and is walk-level indistinguishable everywhere.
  • Text-only packs: write path verified (prompt embeds + zeroed latents + mask=0); walk consumes them without pinning; output quality is the documented checkpoint limitation.
  • Performance reference: 1.5 s/block (Q8_0 + KV cache, RTX 5090), 10.7 s/block (M3 Ultra), scene creation ~3 s (RTX 5090).

Regression safety

Effective changes to existing pipelines at merge time are additive only (verified with a two-dot diff against the base — the Wan VAE first-chunk fix visible in the three-dot view is already on the base branch via #21):

  • New code is reachable only for VERSION_ABOT_WORLD models or through the new sd_abot_* entry points.
  • sd_version_is_wan_ti2v_family() refactors VERSION_WAN2_2_TI2V checks; behavior for existing versions is bit-identical (pure extension to ABot).
  • An optional defaulted attn_mask/context_img_len parameter on WanAttentionBlock::forward; inline on two free functions in tae.hpp (ODR fix).
  • Batch rejection for ABot is covered by script/validate_abot_world.sh, which also (optionally) verifies a stock Wan model still detects and generates unchanged.
  • The abot-parity/abot-walk/abot-session harnesses build only for static libs (they link engine internals the shared library does not export).

olyasir and others added 6 commits July 17, 2026 15:58
In Resample::forward (upsample3d), the first latent-frame chunk skipped
time_conv entirely and never seeded the temporal feat cache, so the second
chunk ran time_conv against zero padding instead of chunk-0 context.

The reference implementation (Wan vae2_2.py, "Rep" cache semantics) runs
time_conv with causal zero padding on chunk 0, doubles the frames, trims
the first one, and stores the input tail in the cache for the next chunk.

Effect on Wan2.2 VAE decode vs the PyTorch reference (5-frame 480x832,
CPU backend, f16 weights): cosine 0.9959 / 27.2 dB PSNR before, cosine
1.000000 / 79.0 dB after. Output was visually near-identical, so this
only shows up in numeric parity testing. Encode and TAEHV paths are
unaffected.
…ction adapter)

ABot-World-0-5B-LF (acvlab, Apache-2.0) is a causal/interactive derivative of
Wan2.2-TI2V-5B with a keyboard-action conditioning adapter. This adds:

- VERSION_ABOT_WORLD: detected via the act_control_adapter.conv.weight tensor;
  helpers sd_version_is_abot_world() / sd_version_is_wan_ti2v_family() (the
  latter now gates the shared TI2V 48ch/16x latent-space branches in
  vae.hpp, tae.hpp and stable-diffusion.cpp instead of == VERSION_WAN2_2_TI2V)
- WAN::ActControlAdapter block (SimpleAdapter in the reference impl:
  conv 2x2/s2 over pixel-unshuffled action planes + one ReLU residual block)
  registered by WAN::Wan when WanParams.abot_world is set, so all 831
  checkpoint tensors (F16/Q8_0 GGUF or safetensors) map and load
- WanRunner: ABot-World-5B config (Wan2.2-TI2V-5B dims + abot_world flag)
- generate_video(): explicit, documented rejection for ABot-World models --
  the causal interactive session (KV cache, per-block actions, 4-step
  distilled schedule) lands in a follow-up; running these weights through the
  batch path executes the wrong recipe by design

No behavior change for any existing model version: every new branch is gated
on VERSION_ABOT_WORLD, and sd_version_is_wan_ti2v_family(v) == (v ==
VERSION_WAN2_2_TI2V) for all previously existing versions.

Verified: ABot Q8_0 GGUF (5.86 GB, 831 tensors incl. adapter) detects as
"ABot-World", instantiates ABot-World-5B, and completes tensor loading;
generation attempt fails fast with the explanatory error.
sd-abot-decode: a native, Python-free example that loads the taehv (taew2_2)
decoder GGUF and a fixed-scene "walk" of precomputed latents (raw f32), decodes
them on the ggml engine, and writes a video. This proves the native taehv
decode path and the S3 -> GPU-runner -> build -> video -> validate CI pipeline
end to end; the DiT causal denoise replaces the "load latents" input in the
follow-up (docs/abot_world_mvp_spec.md).

- examples/abot-decode/{main.cpp,CMakeLists.txt}: standalone decoder (mirrors
  the upscaler.cpp standalone-runner pattern); loads TinyVideoAutoEncoder for
  VERSION_ABOT_WORLD, decodes video latents, writes MJPG-AVI.
- examples/abot-decode/data/walk_latents.{bin,json}: 12-frame golden latent
  walk (from the reference pipeline) as the CI decode input.
- .github/workflows/build.yml: temporary abot-walk-mvp-gpu job on
  qvac-ubuntu2204-x64-gpu — Vulkan build, pull models from S3 (tether-ai-dev via
  OIDC), run the decode, validate the video, upload it as an artifact. Appended
  to the existing workflow so a feature-branch PR can trigger it; to be split
  into abot-walk-mvp.yml at merge.

Builds locally (MinGW, CPU). GPU/S3 path validated on CI.
GPU testing of ABot moves to the diffusion addon (monorepo) via a vcpkg
overlay port, so the engine PR no longer needs the fork-side GPU workflow or
the standalone taehv-decode example:

- revert build.yml to the pristine matrix (drop the abot-walk-mvp-gpu job that
  had no available self-hosted runner in this fork)
- delete .github/workflows/abot-walk-mvp.yml scaffold
- delete examples/abot-decode/ (native taehv decode + committed latent fixture)

This PR is now scoped to engine support only: recognize + load ABot-World
(VERSION_ABOT_WORLD, action-adapter block, TI2V loading) with batch
generate_video() guarded. Docs (docs/abot_world*.md) and the load-validation
script (script/validate_abot_world.sh) are kept.
DmitryMalishev added a commit to tetherto/qvac that referenced this pull request Jul 20, 2026
…test

Builds the diffusion addon against the unmerged sd.cpp PR
tetherto/qvac-ext-stable-diffusion.cpp#22 (ABot-World support) using a vcpkg
overlay port, so ABot can be GPU-tested through the addon's existing
integration CI without merging the engine PR or bumping the shared registry.

- vcpkg/overlay-ports/stable-diffusion-cpp: verbatim copy of the registry port
  (2026-07-03, port-version 5) with REF + SHA512 repinned to PR #22's head
  (da3f6b26). vcpkg-configuration.json gains overlay-ports so it wins over the
  registry port. Temporary — removed when the engine PR merges + the registry
  port is bumped.
- scripts/download-model-abot.sh: fetch the ABot GGUF set (DiT q8_0/f16,
  wan2.2 VAE, taew2_2) from corp S3 (tether-ai-dev, OIDC) with SHA256 check.
- test/integration/abot-world.test.js: skip-guarded. Asserts the ABot set
  loads via the addon and that batch generate is rejected with the documented
  "interactive session not implemented" error (ABot is causal/interactive, not
  one-shot). Auto-discovered by test:integration, so it runs on the existing
  qvac-*-gpu integration workflow. Replaced by a real walk assertion when the
  causal core lands.
Drop the internal implementation-planning spec (belongs with the consuming
project, not this public repo) and remove a reference to non-public tooling
from abot_world.md. No code changes.
Comment thread src/stable-diffusion.cpp Outdated
*num_frames_out = 0;
}
int64_t t0 = ggml_time_ms();
if (sd_version_is_abot_world(sd_ctx->sd->version)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fail-fast guard only covers one of the two doors into generation, and the capability layer disagrees with it.

1. generate_image() is unguarded. sd_version_is_wan() was (correctly) extended to include VERSION_ABOT_WORLD, so ABot travels through all the shared Wan latent/VAE/sampling paths used by both generate_video() and generate_image()/generate_image_internal(). But the rejection was added only here. A caller that loads an ABot GGUF and calls generate_image() runs the bidirectional/one-shot Wan recipe on a causal model with no check at all — silently corrupted output, or a raw GGML_ASSERT abort if it hits a shape assumption that was never true for ABot. That's exactly the hazard this guard exists to prevent.

2. The capability query now lies. sd_version_supports_video_generation() (line 3104) is ... || sd_version_is_wan(version) || ..., so it now reports ABot as video-capable. The example server's /vid_gen route (examples/server/routes_sdcpp.cpp) uses that query to reject unsupported models cleanly before reaching the engine — it will instead wave ABot through to this return false, bypassing the intended clean-rejection path.

Suggested fix: reject in one shared choke point instead of per-entrypoint, and keep the capability query honest:

  • exclude ABot from sd_version_supports_video_generation() (and any image-capability query), e.g. ... && !sd_version_is_abot_world(version);
  • move the actual rejection to a point both entrypoints pass through (GenerationRequest / SamplePlan::resolve()), or reject at new_sd_ctx() load time when the version is VERSION_ABOT_WORLD and no causal-session API was requested.

Loading still works; generation is then refused everywhere with the same message, and the server pre-screen agrees with the engine.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points confirmed and fixed in 23d8fd5 — thanks, this was a real gap.

  • Shared choke point: the rejection moved into GenerationRequest::resolve() (same pattern as validate_ideogram4_uncond_model), so generate_image() and generate_video() are both covered by one check; the per-entrypoint guard here was removed. Message now names both entrypoints.
  • Honest capability queries: sd_version_supports_video_generation() and sd_version_supports_image_generation() both return false for ABot (image could not simply stay !video, or it would have flipped to true).
  • script/validate_abot_world.sh grew an img_gen lane asserting the image path rejects too; verified locally against the ABot Q8_0 GGUF (detection + full load unchanged, both batch paths reject, stock-Wan lane unaffected).

On load-time rejection: kept loading permitted intentionally — downstream (tetherto/qvac#3352) exercises load on GPU CI ahead of the causal session API, and the capability queries now give front-ends the clean pre-screen.

…apability queries

Review feedback (#22): the fail-fast guard covered only generate_video(), while
generate_image() ran the batch recipe on the causal model unchecked, and
sd_version_supports_video_generation() still reported ABot as video-capable,
so front-ends pre-screening on the capability query would wave it through.

- Move the rejection into GenerationRequest::resolve() (the stage both
  generate_image() and generate_video() pass through, same pattern as
  validate_ideogram4_uncond_model): one check now covers every batch door.
  Message updated to name both entrypoints.
- sd_version_supports_video_generation() and
  sd_version_supports_image_generation() both return false for ABot-World
  (image could not simply mirror !video, or it would flip to true).
- Drop the now-redundant guard in generate_video().
- script/validate_abot_world.sh: assert the image path is rejected too;
  docs/abot_world.md wording updated.

Verified locally against the ABot Q8_0 GGUF: detection + full tensor load
unchanged; vid_gen and img_gen both reject with the shared message; stock-Wan
regression lane unchanged.
DmitryMalishev and others added 9 commits July 21, 2026 21:42
Implements the actual interactive walk for ABot-World models (the batch
generate_image()/generate_video() APIs keep rejecting them):

- src/abot_world.hpp: causal walk core using the recompute formulation --
  each denoise step re-derives attention over [ref tokens | clean history
  | current block] with a per-row trailing-window mask, reproducing the
  reference KV cache exactly (including eviction). Absolute-counter RoPE
  with negative-time reference ids; per-frame timestep modulation via a
  row-indexed e0 table; PixelUnshuffle'd keyboard-action planes through
  the act_control_adapter. Includes a minimal scene-pack reader
  (safetensors F32), a seed-stable RNG, and AbotWalkSession combining the
  4-step distilled flow-matching block loop with taehv pixel decoding
  (stateless per-block decode with a 3-latent-frame overlap standing in
  for the reference's streaming decoder cache).
- include/stable-diffusion.h, src/stable-diffusion.cpp: public C API --
  sd_abot_session_{params_init,new,step,frames_free,free}. A session is
  standalone (own DiT + taehv + scene pack, no sd_ctx_t); step() takes an
  8-key action mask and returns the next block's decoded RGB frames.
- src/wan.hpp: WanAttentionBlock::forward takes an optional additive
  self-attention mask (defaulted, no behavior change for existing paths).
- src/tae.hpp: patchify/unpatchify marked inline (they now live in a
  header included from more than one translation unit).

Validation vs the PyTorch reference (F16 GGUF, coastal scene goldens):
- Single-step parity across every regime -- first block, all timesteps,
  history recompute, window onset, deep eviction: 7/7 gates at cosine
  0.9996-0.9999 (gate 0.999).
- Native taehv decode vs reference streaming decode on identical latents:
  PSNR mean 46.4 dB, min 33.5 dB.
Three small executables used to prove the causal walk core against the
PyTorch reference and to exercise the public session C API end to end:

- examples/abot-parity: runs one golden denoise step (dumped by the
  reference implementation) through the causal forward and writes the
  block x0 prediction for offline cosine comparison.
- examples/abot-walk: full block-loop walk over a fixed scene; replays
  the reference's recorded noise for deterministic full-chain validation
  against the golden per-block final latents, or free-walks with a seeded
  RNG. Emits raw latents.
- examples/abot-session: drives the public sd_abot_session_* C API for a
  fixed-scene walk, writing decoded frames as PNGs; --mode decode is a
  taehv-only lane for PSNR cross-checks against the reference decoder on
  identical latents.
The abot-parity/walk/session examples link against engine internals
(ModelLoader, SDBackendManager, the runner classes) that are not
exported from the shared library, so -DSD_BUILD_SHARED_LIBS=ON lanes
failed to link them. Gate the three harnesses to static builds; the
public sd_abot_session_* C API itself is exported like every other
sd_* symbol and is unaffected.
RTX 5090 (32 GiB, sm_120) validation of the causal walk exposed unbounded
per-block growth: every denoise graph carried the full walk history (the
attention window was enforced only by masking), so VRAM grew ~1.3 GiB and
per-block time ~1.6 s per block until CUDA OOM'd at walk block 5
(20.1 -> 29.1 GiB measured at 832x480 F16).

Bounded history (default): each block's graph now carries the refs, walk
block 0 (pinned - ref rows re-derive their K/V against it and its own window
is refs + itself, so it stays exact), the trailing local_attn_size frames
(the current block's exact window), and the current block. RoPE ids and the
mask switch to absolute frame ids so every attended pairwise relation is
unchanged; for walks <= window + fpb frames the kept set equals the full
history, so the golden/parity regime is bit-identical. history_keep < 0
restores the previous full-recompute behavior.

Per-layer KV cache (opt-in, ABOT_KV_CACHE=1): roped K / per-token V of
refs+block0 ("base") and a 5-frame ring (window - fpb) are captured once per
finalized block via the runner's named-cache buffers; denoise graphs then
carry only the current block's rows against the cached context, and a
context pass at t=0 appends the finalized block (reference cache-append
semantics). The cache-append pass overlaps with the taehv decode on a
worker thread; with taehv on a second GPU (backend spec
"diffusion=cuda0,vae=cuda1") measured steady state is 1.9 s/block, flat,
16/16 blocks (previously 8.5 s/block growing, OOM at 5).

Validation on RTX 5090 CUDA (both paths):
- parity 7/7 cases >= 0.9995 (gate 0.999), recompute path bit-identical to
  the unpatched engine
- golden-replay walk chain: recompute 0.99989/0.99872/0.99708/0.99201;
  KV cache 0.99989/0.99866/0.99689/0.99226 (gate 0.99)
- sd-abot-session gains --mode walkval (--golden replay via noise_override,
  --latents-out for compare_walk.py) to gate any walk path end to end

Also adds an opt-in ABOT_FLASH_ATTN=1 toggle: the masked flash-attention
path is ~23% faster but currently produces wrong results for any block with
history (block 0 passes, history blocks collapse to cosine ~0.2 vs goldens
on CUDA) - exposed for debugging the flash mask handling only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two independent defects in the opt-in ABOT_KV_CACHE=1 path, found while
validating PR #22 on Apple Silicon / Metal (platform 2):

1. Captured K/V tensors were graph intermediates that the attention
   itself also consumes, so the graph allocator could reuse their
   memory before the post-compute cache persist read them. CUDA's
   allocation layout happened to keep the bytes intact; on Metal and
   CPU every block with history collapsed to cosine ~0.25 vs goldens.
   Materialize view captures and pin every capture as a graph output.
   No computed value changes on any backend; the plain path and parity
   outputs are bit-identical pre/post fix.

2. The taehv-decode / cache-append overlap ran two graphs concurrently
   on ONE ggml backend instance when DiT and taehv share a device
   (single-GPU Metal), wedging Metal's command queue (command buffer
   stuck in Scheduled, backend poisoned). ggml backends are not
   thread-safe; serialize the overlap unless the two modules run on
   distinct backend instances. The dual-GPU overlap config
   (diffusion=cuda0,vae=cuda1) is unchanged.

Validated on Apple M3 Ultra 96 GB (Metal): KV walkval golden gate PASS
(0.99995 / 0.99897 / 0.99736 / 0.99294), 16-block walk 10.7 s/block
steady vs ~35 s plain, memory flat at ~30 GiB, frames coherent. The
pre-fix failure also reproduced on the CPU backend, confirming the
capture bug was latent on all backends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… lookup)

GGMLBlock::init appends "." to a non-empty prefix; the ABot runner and the
parity/walk harnesses passed "model.diffusion_model." WITH a trailing dot,
so every init_params get_type lookup used a "model.diffusion_model.." key,
missed the tensor storage map, and fell back to GGML_TYPE_F32. The loader
(which composes names itself) still found and converted every tensor, so
loading "worked" - but the whole 5B DiT ran with F32 weights:

- 19.7 GiB of weights in VRAM instead of 9.8 GiB (session peak 30.2 -> 20.7)
- F32 GEMM paths instead of F16 tensor-core paths (KV-cache walk pass
  320 ms -> 244 ms; steady block 1.9 s -> 1.5 s on RTX 5090)
- quantized GGUFs silently dequantized to F32, so Q8_0 changed neither
  VRAM nor speed

Measured after the fix (RTX 5090, 832x480, window 8): parity 7/7 >= 0.9995,
golden-replay chain PASS on both walk paths with block-3 cosine improving
0.992 -> 0.9968 (true-F16 weights match the goldens' F16 provenance more
closely than the F32 conversions did).

Also adds opt-in ABOT_PROF=1 per-phase timing (host prep / graph compute /
taehv decode) used to find this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…first frame)

Scene packs no longer need offline PyTorch extraction: sd_abot_scene_create
builds one on-device from a prompt and a first-frame image.

- prompt -> umT5-XXL embeddings via T5CLIPEmbedder (umt5 tokenizer, seq 512,
  attention-masked, padding rows zeroed) matching the reference WanTextEncoder
  semantics (u[v:] = 0)
- image -> PIL ImageOps.fit-equivalent aspect crop + antialiased Catmull-Rom
  resize (stb) -> Wan2.2 VAE encode -> (z - mean) / std diffusion latents
- zero-filled reference slots + safetensors writer (AbotSceneWriter)
- sd-abot-session gains --mode create-scene

Validated against the reference coastal pack (F16 models, CPU backend):
prompt_embeds cosine 0.9973, first_frame_latents cosine 0.9987 (gate 0.995).
Encoders load standalone and are freed before returning; the walk session
path is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
…int-limited

sd_abot_scene_create now accepts a null init_image: the pack is written with
a zero first-frame latent and a new first_frame_mask tensor ([1,1], 0 = no
first frame; missing = 1 for full back-compat), and the walk skips the three
block-0 pinning sites for such packs so frame 0 is generated from noise under
the prompt alone (TI2V-style). sd-abot-session --mode create-scene makes
--image optional (--vae required only alongside an image).

Validated on RTX 5090 (2026-07): the plumbing works end to end, but the
distilled ci2v checkpoint cannot bootstrap a coherent first frame in its 4
distilled steps - text-only scenes render as degenerate, non-navigable
texture that does not recover over blocks. The capability is therefore kept
gated (front-ends should require an image) and becomes useful only with a
T2V-capable checkpoint; the scene format change is forward-compatible.

Also documents validated image-input behavior at the create API: stb decode
is magic-byte based (extension irrelevant; JPEG/PNG reliable, no
WebP/AVIF/HEIC), EXIF orientation is not honored, alpha is dropped, and
arbitrary input resolutions are cover-scaled + center-cropped (small inputs
upscale softly; extreme aspect ratios lose the periphery).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review-pass cleanup, no functional changes:

- docs/abot_world.md: rewritten to describe what actually shipped - the
  interactive session C API, the opt-in KV cache, native scene creation,
  and the gated text-only pack support - instead of the pre-session
  "generation not yet supported" status. Adds the model-files table.
- stable-diffusion.cpp: the batch-rejection error and capability-query
  comment no longer claim the session API "is not implemented yet"; they
  point at sd_abot_session_* (the validate script's grep contract is
  unchanged). The text-only branch of sd_abot_scene_create is restructured
  into a properly indented if/else (was a non-reindented wrap ending in a
  confusing double brace), and the completion log now says "first frame
  zeroed - text-only scene" instead of the misleading "encoded".
- stable-diffusion.h: sd_abot_scene_params_t documents the optional
  init_image/vae_path (text-only pack semantics and its gating).
- validate_abot_world.sh: comment updated for the landed session API.

Validated after the restructure (CPU, F16 encoders): scene pack vs the
golden extraction - prompt_embeds cosine 0.9973, first_frame_latents
0.9987, SCENE PASS (identical to the pre-restructure numbers); text-only
pack writes prompt embeds + zeroed latents + first_frame_mask=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DmitryMalishev DmitryMalishev changed the title Feature abot dit QVAC-ABOT feat: ABot-World interactive walk sessions + native scene creation Jul 31, 2026
@DmitryMalishev DmitryMalishev changed the title QVAC-ABOT feat: ABot-World interactive walk sessions + native scene creation QVAC-21981 feat: ABot-World interactive walk sessions + native scene creation Jul 31, 2026
Comment thread src/abot_world.hpp
for (int f = 0; f < Fb; f++) {
out_frames.emplace_back(xt.begin() + static_cast<long long>(f) * fel,
xt.begin() + static_cast<long long>(f + 1) * fel);
history.push_back(out_frames.back());

@aegioscy aegioscy Aug 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These writes commit the session state before the initial KV capture below has succeeded. If INIT_CAPTURE fails, step() returns failure with non-empty history but no base cache. A subsequent call sees first == false, takes kv_fast, and reaches the GGML_ASSERT(k_all != nullptr && v_all != nullptr) in the cache provider. Decode and deferred-append failures can likewise leave history, ring metadata, and backend cache tensors at different logical block numbers.

Because backend cache writes may already be partially applied when a graph fails, rollback does not look reliable. I suggest making the session explicitly fail-stop. For example, add terminal state to AbotWalkSession:

bool failed = false;

bool fail_session(const char* reason) {
    if (!failed) {
        LOG_ERROR("abot session entered terminal failure state: %s; recreate the session", reason);
    }
    failed = true;
    kv_append_pending = false;
    return false;
}

Then reject every later entry before it can inspect history or use the cache:

bool step_latents(/* ... */) {
    if (failed) {
        LOG_ERROR("abot session: step called after terminal failure; recreate the session");
        return false;
    }
    // ...
}

bool step(/* ... */) {
    if (failed) {
        LOG_ERROR("abot session: step called after terminal failure; recreate the session");
        return false;
    }
    // ...
}

Stateful failures should poison the session rather than returning a reusable object, for example:

if (flow.empty()) {
    return fail_session("KV initial capture failed");
}

if (px.empty()) {
    return fail_session("frame decode failed");
}

The same should cover denoising and append failures: even before history is committed, denoising has advanced the RNG, so retrying would no longer reproduce the same block. The C API documentation should state that a failed sd_abot_session_step() requires freeing and recreating the session.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — issue confirmed: a failed INIT_CAPTURE returned with history already committed but no base cache, so the next step took the kv_fast path into the cache provider GGML_ASSERT; and with the RNG advanced no failure is retryable anyway. Fix is the fail-stop you suggested: terminal failed flag + fail_session(), entry guards in step()/step_latents(), poisoning at every stateful failure point (denoise forward, INIT_CAPTURE, append, decode), and the terminal-failure contract documented on sd_abot_session_step(). Submitted in 81a0f02 — success paths verified bit-identical (2-block KV walk, block-0 frames at infinite PSNR vs pre-change).

Comment thread src/abot_world.hpp Outdated
fts.push_back(cfg.context_noise_t);
abs_ids.push_back(total - Fb + f);
write_slots.push_back(kv_ring_next);
kv_ring_next = (kv_ring_next + 1) % AbotWorldRunner::kv_ring_slots;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mutates kv_ring_next before forward_step_kv() has succeeded, and kv_ring_abs is also updated before the flow.empty() check below. Even with a terminal-failure flag, it would be safer to commit host-side bookkeeping only after successful execution. For example:

int next_ring_slot = kv_ring_next;
for (int f = 0; f < Fb; ++f) {
    frames.push_back(kv_append_frames[f].data());
    fts.push_back(cfg.context_noise_t);
    abs_ids.push_back(total - Fb + f);
    write_slots.push_back(next_ring_slot);
    next_ring_slot = (next_ring_slot + 1) % AbotWorldRunner::kv_ring_slots;
}

auto ring_before = kv_ring_abs;
auto flow = runner->forward_step_kv(/* ... */);
if (flow.empty()) {
    return fail_session("KV append failed");
}

kv_ring_next = next_ring_slot;
for (int f = 0; f < Fb; ++f) {
    kv_ring_abs[write_slots[f]] = abs_ids[f];
}
kv_append_pending = false;
return true;

kv_append_pending is currently cleared at function entry as well; that should move to the successful commit section. This cannot roll back backend writes, which is why the terminal flag is still needed, but it prevents the host metadata from advertising an append that never completed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — confirmed, all three instances: kv_ring_next advanced during setup, kv_ring_abs written before the flow.empty() check, and kv_append_pending cleared at entry (so a failed append would be silently skipped on the next call). Fix is exactly the shape you sketched: slots computed in a local next_ring_slot/write_slots, the append graph reads the untouched pre-append kv_ring_abs, and kv_ring_next/kv_ring_abs/kv_append_pending commit only after successful execution (with the terminal flag from the other thread covering the backend side). Submitted in 81a0f02.

…on success

Review follow-ups on the KV-cache walk state machine:

- kv_run_append() committed host-side ring state before the append graph
  ran: kv_ring_next advanced while building the pass, kv_ring_abs was
  written before the flow.empty() check, and kv_append_pending was
  cleared at entry - a failed append left the ring metadata advertising
  K/V that was never captured, and a retry would silently skip the block.
  Ring slots are now computed locally and all bookkeeping (kv_ring_next,
  kv_ring_abs, kv_append_pending) commits only after the graph succeeds.

- Stateful step failures now poison the session (fail_session + terminal
  `failed` flag checked at step()/step_latents() entry). A failed
  INIT_CAPTURE used to return with history already committed but no base
  cache, so the next step took the kv_fast path into the cache provider's
  GGML_ASSERT; decode/append failures could likewise leave history, ring
  metadata, and backend cache tensors at different logical block numbers.
  Backend cache writes may be partially applied when a graph fails, and
  the RNG has advanced either way, so no failure is retryable: every
  later step now fails immediately with a clear message instead.

- C API: sd_abot_session_step() documents the terminal-failure contract
  (free the session and create a new one).

Success paths are bit-identical: a 2-block ABOT_KV_CACHE=1 walk on the
golden scene (Q8_0 DiT, CPU, seed 42) reproduces the pre-change block-0
frames exactly (infinite PSNR, 9/9 frames) and exercises the reordered
append commit on block 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Status

Current Status: ❌ PENDING
Approvals so far: Member: 1

Pending reviews: Needs 1 Management or Team Lead.

@olyasir olyasir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few notes on how the three walk toggles are configured. The short version: sd_abot_session_params_t already carries exactly this class of knob (n_threads, seed, num_frame_per_block, local_attn_size, offload_params_to_cpu), so flash_attn and kv_cache look like they belong there rather than in getenv. Note that sd_ctx_params_t.flash_attn isn't reachable from here — the session is standalone by design (stable-diffusion.h:507, "no sd_ctx_t needed"), so it's a precedent for how the codebase models this, not a struct you can read the value from. Details inline.

Comment thread src/abot_world.hpp
// KNOWN ISSUE: parity passes for block 0 but collapses for any block
// with history (cosine ~0.2 vs goldens on CUDA) - the flash path
// mishandles the walk's history mask. Left opt-in for debugging only.
if (const char* fa = std::getenv("ABOT_FLASH_ATTN"); fa != nullptr && fa[0] == '1') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is worth a second look, less for the style than for the failure mode.

The comment directly above states the path produces wrong output for any block with history (cosine ~0.2 vs goldens). Shipping that as a process-wide env var means:

  • it's set once for the process, with no per-session or per-call scoping — a front-end that runs an ABot session alongside other work can't enable it for one and not the other;
  • there's no signal in the output that it was on. LOG_INFO at :970 is the only trace, so a stray ABOT_FLASH_ATTN=1 in a shell profile or a CI env produces silently degraded walks that look plausible;
  • it can't be exercised or asserted against from a test that doesn't shell out.

The codebase already models this exact concept as a params field — sd_ctx_params_t.flash_attn / diffusion_flash_attn (include/stable-diffusion.h:218-219). That struct isn't reachable here (the session is deliberately standalone, no sd_ctx_t), so the equivalent would be a bool flash_attn; on sd_abot_session_params_t, defaulted off in sd_abot_session_params_init.

Given it's documented as broken, the other reasonable option is to not expose it at all right now — put it behind a build-time define, or drop it until the history-mask handling is fixed. An opt-in debug path is fine; an opt-in debug path reachable by an environment variable in a shipped shared library is the part I'd push back on.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, issue confirmed — a known-broken path one ambient env var away is not acceptable in a shipped library. Fix: ABOT_FLASH_ATTN is no longer read in production builds; the flash path is gated behind the SD_ABOT_FLASH_ATTN_DEBUG compile define (with a LOG_WARN when enabled there). The abot-parity/abot-walk harnesses keep their env toggles — they exist to debug exactly this. Submitted in 52b231e.

Comment thread src/abot_world.hpp Outdated
// K/V are captured once per finalized block instead of recomputed
// every denoise step, so steady-state block graphs carry only the
// current block's rows (~3.7x fewer frame-passes per block).
if (const char* kc = std::getenv("ABOT_KV_CACHE"); kc != nullptr && kc[0] == '1') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same suggestion as the flash-attn toggle above, though with less urgency since this path is validated rather than known-broken: this reads naturally as bool kv_cache; on sd_abot_session_params_t next to the local_attn_size / num_frame_per_block fields it interacts with.

The interaction is the actual argument for moving it. kv_ring_slots is a compile-time 5 (:551) while local_attn_size is caller-settable, and the two are only consistent at the default 8/3 — but the value that decides whether that matters arrives through a completely different channel (env) than the value that breaks it (params). With both on the same struct, sd_abot_session_new can cross-validate them in one place and fail fast, instead of the mismatch only showing up as quietly-wrong attention several blocks into a walk.

Also worth noting kv_decode_overlap_safe is derived here from backend identity — that's good, and it's the kind of thing that's much easier to reason about when the enabling flag is a visible field rather than ambient process state.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed — fix is bool kv_cache on sd_abot_session_params_t (env var no longer read; the API is unreleased so no compat impact), and with both values finally in one struct, session load now cross-validates the window against the compiled ring and fails fast. Byte-identical KV walk vs the old env path (21/21 frames). Submitted in 52b231e.

Comment thread src/abot_world.hpp Outdated
}
}
sd_tiling_params_t no_tiling = {};
static const bool prof = std::getenv("ABOT_PROF") != nullptr;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ABOT_PROF is the most defensible of the three env vars — it's pure diagnostics with no effect on computed output, it's read once into a static const, and this codebase does have precedent for that shape (SD_CPU_ONLY, SD_VK_DEVICE, GGML_ROPE_FLUX_DISABLE).

No objection to leaving it as-is. Flagging only for consistency if flash_attn / kv_cache do move onto sd_abot_session_params_t — at that point a bool profile; alongside them costs nothing and keeps all three discoverable from the header. Your call; this one I'd genuinely not block on.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — added bool profile alongside the other two for discoverability; ABOT_PROF stays as a fallback for field debugging, per your non-blocking note. Submitted in 52b231e.

int64_t seed; // walk noise seed
int num_frame_per_block; // <= 0: model default (3)
int local_attn_size; // <= 0: scene/deploy default; latent-frame window
bool offload_params_to_cpu;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This struct is where I'd expect the three walk toggles to live. It already carries the same class of configuration — thread count, seed, block size, attention window, param offload — so bool flash_attn; and bool kv_cache; here (defaulted off in sd_abot_session_params_init) would make the full behaviour of a session readable from the header, settable per-session, and reachable from language bindings that don't control the process environment.

As it stands, two settings that materially change both output correctness and memory profile are invisible at this API boundary: a binding author reading this header has no way to discover that ABOT_FLASH_ATTN or ABOT_KV_CACHE exist, or that one of them is documented as producing wrong results.

Separate small thing on the neighbouring field: local_attn_size at :520 documents <= 0 as "scene/deploy default", but nothing carries a deploy value — no field in the scene pack or the model holds one — so 0 always resolves to the compiled-in 8, which abot_world.hpp:57 calls the test config (vs. 21 deployed). Either the comment should name the real fallback or the value needs to come from somewhere.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed on both points. kv_cache and profile now live on this struct (flash-attn is compile-gated as known-broken, so deliberately not exposed here), making the toggles discoverable and per-session. The local_attn_size comment now names the real fallback: compiled default 8, with a note that the upstream deployed config uses 21 and that kv_cache validates the window at load. Submitted in 52b231e.

@olyasir olyasir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review pass over the walk core. The engineering here is strong — the parity validation is thorough, the KV commit-ordering fix in 81a0f02 is correct, and the sd_version_is_wan_ti2v_family refactor is clean. Findings inline, roughly in the order I'd act on them:

Would want resolved before merge

  • AbotScenePack::load performs no validation on anything it reads from the pack file (4 separate memory-safety issues, all inline below). Severity depends on whether packs are ever externally sourced — see the question on :104.
  • kv_ring_slots is a compile-time constant that the public API lets callers silently invalidate (:551), producing quietly-wrong attention with no error.

Follow-up material, not blocking

  • Host-side history still grows without bound (:869).
  • Per-block-invariant buffers rebuilt every denoise step, including a ~1.4 GB action tensor (:481).

Separately: the areas I checked and found correct — the terminal-failure poisoning, the three block-0 pinning sites, the ref_latents transpose, decode-overlap frame accounting, and the C API partial-failure cleanup. Nothing to report on those.

Comment thread src/abot_world.hpp
}
uint64_t hlen = 0;
f.read(reinterpret_cast<char*>(&hlen), 8);
std::string header(hlen, '\0');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, the question that sets the severity of this and the next three comments: is a scene pack ever externally sourced?

  • Always generated locally by sd_abot_scene_create and never moved between machines → these are robustness nits, fine to defer.
  • Ever downloaded, shared, or served from a registry → this parser is an untrusted-input surface reachable straight from the public API (sd_abot_session_newscene.load(scene_path), :993) and should be hardened before shipping.

The format looks built to be shareable — portable safetensors, the artifact a session consumes — which is why I'm asking rather than assuming.


Here, hlen comes from the first 8 bytes of the file and goes straight into std::string header(hlen, '\0') with no upper bound. Sixteen bytes of input (hlen = 0xFFFFFFFFFFFFFFFF) forces a multi-exabyte allocation. Real safetensors loaders cap header size for exactly this reason.

Also on the next line: f.read(header.data(), hlen) doesn't check gcount(), so a truncated file leaves header silently zero-padded and parsing proceeds over the padding rather than failing.

Worth noting the blast radius regardless of provenance: these entry points are inside the header's extern "C" block and there's no try/catch anywhere in the chain, so bad_alloc / length_error propagates across a C-linkage boundary. For an embedding host — the Node/Bare addon path — that's a process kill, not an error return. A try/catch at the C API boundary is worth having on its own merits.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — and to answer the question: yes, packs are externally sourced in practice (the upstream HF repo ships downloadable reference packs, and packs move between machines in our validation flows), so the untrusted case applies. The parser now treats every field as untrusted: header length capped at 16 MB with gcount checks, and the whole chain sits behind an exception barrier at the extern "C" boundary (session_new/step, scene_create) so bad_alloc and friends degrade to nullptr/false instead of terminating the embedding host. Validated with 8 hostile packs — all clean rejections, no crashes. Submitted in 52b231e.

Comment thread src/abot_world.hpp Outdated
return false;
}
size_t sp = header.find("\"shape\"", p);
size_t sb = header.find('[', sp), se = header.find(']', sb);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sp, sb and se are never tested against npos (same applies to op / ob at :123-125).

If a pack contains the tensor name but no "shape" key after it, sp == npos; header.find('[', npos) legally returns npos, so sb == se == SIZE_MAX. The loop on the next line then starts at sb + 1, which wraps to 0, with the bound q < se effectively unbounded. From there the only thing stopping it is a character match against header[q] — and std::string::operator[] is unchecked, so once q passes header.size() this is an out-of-bounds heap read that runs until it happens to hit a byte satisfying the exit condition. strtoll(header.c_str() + q, ...) at :120 reads past the end too.

This is reachable with a merely truncated pack, not just a crafted one, because of the unchecked gcount() noted on :104.

Fix is mechanical: check each find() result against npos and return false before doing any arithmetic derived from it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed (including the npos+1 wraparound on the data_offsets find). Fix: every find() is npos-checked and a tensor with a missing shape/data_offsets fails the load with a named error. Submitted in 52b231e.

Comment thread src/abot_world.hpp
if (*it != 1 || ne.size() < 1) ne.push_back(*it);
}
while (ne.size() < 1) ne.push_back(1);
out.resize(ne);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

n (computed at :126-127) is the raw product of file-supplied dimensions with no overflow check, and out.resize(ne) is sized directly from it with no cap.

Two distinct failure modes:

  1. Large-but-not-overflowing dims (say [2000000000, 2000000000]) → multi-exabyte allocation attempt → bad_alloc → uncaught across the C boundary, as on :104.
  2. Dims whose product overflows int64_t wrap to an unrelated small or negative value, so the tensor's declared shape and its actually-allocated buffer disagree — downstream indexing uses the un-wrapped shape metadata. Classic size-confusion setup.

Note tensor_numel (src/tensor.hpp:47-60) only rejects negative dims, and does so by throwing std::invalid_argument — which is the same uncaught-exception path again.

Scene-pack tensors are small fixed-purpose objects (prompt embeds, one first-frame latent, a handful of ref slots), so a generous hard cap on element count plus an explicit overflow check on the dimension product would cost nothing and close both.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed — both failure modes. Fix: dims must be positive, the element product is overflow-safe and capped at 256M elements (1 GiB, far above the largest legitimate tensor), and each tensor's data range is checked against the actual file size before reading. Submitted in 52b231e.

Comment thread src/abot_world.hpp
if (!fetch("prompt_embeds", prompt_embeds, sh)) { // [1,512,4096]
return false;
}
prompt_embeds.resize({sh[2], sh[1], 1, 1});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sh[2] is indexed with no check that torch_shape actually has rank 3 — and sh was populated purely from whatever digits appeared between [ and ] in the header. Same pattern at :150 (sh[4], sh[3], needs rank 5) and :164-165 (sh[5], needs rank 6).

A pack declaring prompt_embeds with a rank-1 or rank-2 shape is perfectly well-formed JSON and gets an out-of-bounds std::vector read here. This is the cheapest of the four to trigger — no truncation or crafted npos needed, just a wrong-rank shape.

Suggest validating rank per named tensor right after the fetch call (3 / 5 / 6 respectively) and failing closed before touching any sh[i].

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed. Fix: an explicit rank + leading-singleton layout check per named tensor (prompt_embeds rank 3, first_frame_latents rank 5, ref_latents rank 6) before any fixed-position indexing or reinterpreting resize; ref_mask must also match ref_latents' slot count. A rank-2 prompt_embeds pack now fails cleanly. Submitted in 52b231e.

Comment thread src/abot_world.hpp
// K stored {d_head, T, n_head} (roped), V stored {T, d_head, n_head}.
// The mask column layout every cached graph uses:
// [ base(refs + block0) | r0..r{R-1} | current rows ]
static constexpr int kv_ring_slots = 5; // window(8) - fpb(3): the current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constant is derived from the test config, but local_attn_size and num_frame_per_block are both caller-settable via sd_abot_session_params_t and are wired in at src/stable-diffusion.cpp:5825-5830 with no cross-validation against it. kv_ring_abs (:879) is a fixed std::array of this size.

The mismatch: build_mask_kv:599 computes the window bound as hi - cfg.local_attn_size, but only kv_ring_slots history columns physically exist in the ring. The non-KV path scales correctly — history_keep at :1072 derives keep from cfg.local_attn_size — so the recompute path honours the configured window and the KV path cannot.

Concrete scenario: a front-end sets local_attn_size = 21 (the value the comment at :57 calls the deployed default, vs. the 8 it calls the test config) and enables ABOT_KV_CACHE=1 for the documented speedup. After 5 finalized blocks the ring wraps, and frames still inside the intended 21-frame window aren't masked out — they're structurally absent as K/V columns. From there the DiT attends a ~5-frame window instead of 21. No assert, no log, no error; the walk keeps producing plausible frames with quietly wrong temporal context, diverging from both the recompute path and the reference.

script/validate_abot_world.sh and all three examples/abot-* harnesses build AbotWorldConfig cfg; at its defaults, so this combination is uncovered.

Either:

  • fail fast in AbotWalkSession::load() when kv_enabled && (cfg.local_attn_size - cfg.num_frame_per_block > kv_ring_slots), naming both values — turns silent wrong output into a startup error; or
  • size the ring at runtime from cfg (std::vector rather than std::array, and the per-layer abot.kv.l{i}.{k,v}.r{s} tensors with it), mirroring what the non-KV path already does.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed — this was the highest-value catch of the pass. Fix is your fail-fast option, made possible by moving kv_cache onto the params struct: session load rejects kv_cache when local_attn_size - num_frame_per_block exceeds the compiled ring, with an error naming both values. Verified: kv_cache + local_attn_size=21 now fails at load; the default 8/3 KV walk is byte-identical to before (21/21 frames). The runtime-sized ring stays on the follow-up list (QVAC-21981). Submitted in 52b231e.

Comment thread src/abot_world.hpp
std::shared_ptr<TinyVideoAutoEncoder> tae;

// finalized walk state (per-frame ggml {W,H,C} latents, torch [C,H,W] flat)
std::vector<std::vector<float>> history;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only ever appended to (:1165) and never trimmed, so it grows for the lifetime of the session.

history_keep bounds what enters the compute graph (the kept subset at :1072-1088), not this backing store — so the bounded-memory work in this PR fixes VRAM growth but leaves the host-side buffer with the shape it set out to eliminate. Nothing beyond max(keep, decode_overlap) + Fb frames from the tail is ever read again, yet all of it stays resident.

At the documented default latent size a frame is 52*30*48 floats ≈ 292 KB, so at Fb = 3 that's ~880 KB per block, uncapped — a couple of GB per hour of continuous walking, on an API whose whole purpose is open-ended interactive sessions. Memory-constrained targets will hit it first.

Flagging that this is not a one-liner: history.size() doubles as the absolute-frame counter at :909, :1039, :1108 and :1208, so trimming needs a logical frame-index offset threaded through those sites to keep abs_ids correct after eviction. A bounded ring sized to max(keep, decode_overlap) + Fb plus an explicit frames_emitted counter would do it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed — and agreed it is not a one-line fix (history.size() doubles as the absolute frame counter at four sites). Deferred as a follow-up per your note, tracked under QVAC-21981: bounded ring/deque sized to max(keep, decode_overlap) + Fb plus an explicit frames_emitted counter.

Comment thread src/abot_world.hpp
}
}

sd::Tensor<float> act({lat_w, lat_h, c_unsh, F_vis});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tensor is rebuilt on every denoise step, and it's very large.

c_unsh = cfg.act_in_dim * ds * ds = 8192 (:463), so at 832x480 with F_vis ≈ 27 this is 52 * 30 * 8192 * 27 ≈ 345M floats — ≈1.4 GB allocated, filled and host→device copied per step, ~5.5 GB per block across the 4 steps in cfg.denoise_steps. And fill_act_plane (:437-443) fills each channel plane with a single constant, so that 1.4 GB is carrying 8 bits of keyboard state.

The rebuild is avoidable regardless of size: inside the denoise loop at :1090, kept, action_mask and frame_abs_ids are all invariant — only xt and the timestep change. Yet build_pe / build_mask run unconditionally at :502-503 (and :665-670 on the KV path), as does this fill.

Two independent wins:

  • Hoist build_pe / build_mask / fill_act_plane / rows out of the s loop in step_latents, compute once per block, pass the already-uploaded backend tensors in, and rebuild only x_all / tvec. Straightforward ~4x on this cost.
  • Don't materialize the action planes at all — whatever the adapter conv needs, a broadcast of 8 bits shouldn't require a multi-GB dense tensor per step.

The KV path is much cheaper (scoped to the current block: mask ~26 MB, action ~153 MB) but has the same redundant 4x rebuild. Minor related nit: zero_k / zero_v (:659-664) are allocated and zero-filled every KV step even once the ring is fully populated and no slot has ring_abs[s] < 0.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, confirmed — the per-step rebuild of block-invariant buffers (incl. the ~1.4 GB action tensor on the recompute path) is real. Deferred as a follow-up per your note, tracked under QVAC-21981: hoist pe/mask/action out of the denoise loop (~4x on that cost), then stop materializing the broadcast planes entirely. The zero_k/zero_v nit rides along.

…rser

Review feedback, round 2 (PR #22).

Walk toggles (were environment variables - process-global, invisible at
the API boundary, and the two values that must agree arrived through
different channels):

- sd_abot_session_params_t gains kv_cache and profile; ABOT_KV_CACHE is
  no longer read (the session API is unreleased, so no compat impact)
- kv_cache is cross-validated at load: local_attn_size -
  num_frame_per_block must fit the compile-time KV ring (5); violations
  now fail session creation with an error naming both values, instead of
  silently attending a truncated window once the ring wraps
- ABOT_FLASH_ATTN moves behind the SD_ABOT_FLASH_ATTN_DEBUG compile
  define: the flash path is documented broken for any block with history,
  so it is no longer reachable in production builds (the abot-parity/walk
  harnesses keep their env toggles - they exist to debug exactly this)
- profile keeps ABOT_PROF as a fallback; local_attn_size <= 0 is now
  documented as the compiled default (8) - nothing carries a "deploy"
  value; sd-abot-session gains --kv-cache / --profile / --local-attn

Scene-pack parser (packs are portable artifacts - downloaded reference
packs, packs shared between machines - so every field is now treated as
untrusted input):

- header length bounded (16 MB) and gcount-checked; truncated headers
  fail instead of silently parsing zero padding
- every find() checked against npos (a missing "shape" was an unbounded
  scan; a missing '[' wrapped npos+1 to 0 and parsed garbage offsets)
- dims must be positive; element count capped (256M) with an
  overflow-safe product; tensor data ranges checked against file size
- per-tensor rank + leading-singleton layout checks before the
  fixed-position indexing and reinterpreting resizes
- ref_mask must match ref_latents' slot count; ref slots capped (64)
- malformed optional tensors fail the load instead of being skipped
- exception barrier at the extern "C" boundary (session_new/step,
  scene_create): bad_alloc and friends degrade to nullptr/false instead
  of terminating the embedding host; an exception mid-step is a terminal
  session failure; session_new no longer leaks on failure (unique_ptr)

Validated: 2-block KV walk via params byte-identical (21/21 frames) to
the pre-change env-toggled walk; kv_cache + local_attn_size=21 fails
fast; ABOT_KV_CACHE / ABOT_FLASH_ATTN inert; 8 hostile packs (absurd or
truncated header, missing shape, rank-2 prompt_embeds, negative dims,
overflow dims, out-of-file offsets, short ref_mask) all fail cleanly
with exit 1 and a scene-pack error, no crashes; golden, native and
text-only packs load and walk unchanged (default-path frames
byte-identical to the pre-change baseline); scene creation gates
unchanged (SCENE PASS vs the golden extraction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@olyasir olyasir left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All 11 review comments from the previous pass are addressed in 52b231e, verified against the code:

Scene-pack parser — header length bounded and gcount-checked, every find() npos-guarded, dims validated positive with an overflow-safe capped element product, data ranges checked against file size, and per-tensor rank/layout checks before the fixed-position indexing. The bad flag so malformed optional tensors fail the load rather than being silently skipped, and the exception barrier across all three extern "C" entry points (with session_new moved to unique_ptr so it no longer leaks on failure), both go beyond what was asked. Thanks for confirming packs are externally sourced — that settles the severity question rather than leaving it hypothetical.

kv_ring_slots — the fail-fast guard at session load, naming both values, is the right call for now. Converting a silently truncated attention window into a startup error is the important part.

Walk toggleskv_cache and profile on sd_abot_session_params_t, ABOT_KV_CACHE gone from src/ entirely, and the known-broken flash path behind SD_ABOT_FLASH_ATTN_DEBUG with a LOG_WARN. The harness-side env toggles staying put makes sense — that's what they're for.

Two things for the follow-up, neither blocking:

  1. The deferred items (unbounded host-side history, and the per-step rebuild of block-invariant buffers including the ~1.4 GB action tensor) are tracked under QVAC-21981 — this PR's own ticket. If that closes on merge they'll go with it; worth splitting onto a separate ticket.
  2. With the ring still compile-time sized, kv_cache + local_attn_size=21 is now rejected rather than supported. If 21 is genuinely the deployed config, the KV cache is unavailable there until the runtime-sized ring lands — flagging in case that ordering matters for whoever consumes this first.

Note that my verification covers the code, not the runtime results: the byte-identical walk, the 8 hostile-pack rejections, and the fail-fast check are your reported numbers, which I have no build here to reproduce.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants